package cc.aliza.lib.mongo.core;

import cc.aliza.lib.mongo.core.annotations.Id;
import cc.aliza.lib.mongo.core.annotations.Ref;
import cc.aliza.lib.mongo.core.cache.FieldsCache;
import cc.aliza.lib.mongo.core.exception.DBQueryException;
import cc.aliza.lib.mongo.core.utils.IdUtil;
import cc.aliza.lib.mongo.core.utils.MapperUtil;
import cc.aliza.lib.mongo.core.utils.Operator;
import cc.aliza.lib.mongo.core.utils.ReferenceUtil;
import com.mongodb.client.FindIterable;
import com.mongodb.client.MongoCollection;
import com.mongodb.util.JSON;
import org.bson.Document;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Serializable;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.*;
import java.util.regex.Pattern;

/**
 * 『给时光以生命·给岁月以文明』
 *
 * @author Zhang Jing (byrdkm17@gmail.com).
 */
@SuppressWarnings("unchecked")
public class AlizaQuery<T> implements Serializable {

    private static final Logger logger = LoggerFactory.getLogger(AlizaQuery.class);

    private final Document condition = new Document();
    private AlizaDao<T> dao;
    private Document slices;
    private Document fields;
    private boolean fieldsSpecified = false;
    private String orderBy;
    private int pageNumber = 0;
    private int pageSize = 0;

    public AlizaQuery(AlizaDao<T> dao) {
        this.dao = dao;
    }

    private AlizaQuery(AlizaDao<T> dao, Map<String, Object> map) {
        this.dao = dao;
        this.condition.putAll(map);
    }

    private void appendEquals(String key, String op, Object value) {
        Class<T> clazz = dao.getEntityClass();
        if (key.equals(Operator.ID)) {
            append(Operator.ID, op, IdUtil.toDbId(clazz, value));
        } else if (key.contains(".")) {
            append(key, op, value);
        } else {
            Field field = FieldsCache.getInstance().getField(clazz, key);
            if (field.getAnnotation(Id.class) != null) {
                append(Operator.ID, op, IdUtil.toDbId(clazz, value));
            } else if (field.getAnnotation(Ref.class) != null && value instanceof String) {
                append(key + ".$id", op, new ObjectId(String.valueOf(value)));
            } else if (value instanceof AlizaEntity) {
                append(key, op, ReferenceUtil.toDBRef((AlizaEntity) value));
            } else {
                append(key, op, value);
            }
        }
    }

    private void appendThan(String key, String op, Object value) {
        Class<T> clazz = dao.getEntityClass();
        if (key.equals(Operator.ID)) {
            append(Operator.ID, op, IdUtil.toDbId(clazz, value));
        } else if (key.contains(".")) {
            append(key, op, value);
        } else {
            Field field = FieldsCache.getInstance().getField(clazz, key);
            if (field.getAnnotation(Id.class) != null) {
                append(Operator.ID, op, IdUtil.toDbId(clazz, value));
            } else {
                append(key, op, value);
            }
        }
    }

    public Document getCondition() {
        return condition;
    }

    public AlizaQuery<T> in(String key, List list) {
        if (list == null) {
            list = Collections.emptyList();
        }
        return in(key, list.toArray());
    }

    public AlizaQuery<T> in(String key, Object... values) {
        appendIn(key, Operator.IN, values);
        return this;
    }

    private void appendIn(String key, String op, Object... values) {
        if (key.equals(Operator.ID)) {
            append(Operator.ID, op, toIds(values));
        } else if (key.contains(".")) {
            append(key, op, values);
        } else {
            Field field = FieldsCache.getInstance().getField(dao.getEntityClass(), key);
            if (field.getAnnotation(Id.class) != null) {
                append(Operator.ID, op, toIds(values));
            } else if (values.length != 0 && values[0] instanceof AlizaEntity) {
                append(key, op, toReferenceList(values));
            } else {
                append(key, op, Arrays.asList(values));
            }
        }
    }

    private void append(String key, String op, Object value) {
        if (value.getClass().isArray()) {
            List tmp = new ArrayList<>();
            int size = Array.getLength(value);
            for (int i = 0; i < size; i++) {
                tmp.add(Array.get(value, i));
            }
            value = tmp;
        }
        if (op == null) {
            condition.put(key, value);
        } else {
            Object obj = condition.get(key);
            Document document;
            if (!(obj instanceof Document)) {
                document = new Document(op, value);
                condition.put(key, document);
            } else {
                document = (Document) condition.get(key);
                document.put(op, value);
            }
        }
    }

    private List<Object> toIds(Object... values) {
        List<Object> ids = new ArrayList<>();
        Class<T> clazz = dao.getEntityClass();
        Arrays.stream(values).filter(o -> o != null).forEach(o -> ids.add(IdUtil.toDbId(clazz, o)));
        return ids;
    }

    private List<Object> toReferenceList(Object... values) {
        List<Object> refList = new ArrayList<>();
        Arrays.stream(values).filter(o -> o != null).forEach(o -> refList.add(ReferenceUtil.toDBRef((AlizaEntity) o)));
        return refList;
    }

    public AlizaQuery<T> is(String key, Object value) {
        appendEquals(key, null, value);
        return this;
    }

    public AlizaQuery<T> notEquals(String key, Object value) {
        appendEquals(key, Operator.NE, value);
        return this;
    }

    private AlizaQuery<T> operator(String operator, AlizaQuery... queryArr) {
        List list = (List) condition.get(operator);
        if (list == null) {
            list = new ArrayList<>();
            condition.put(operator, list);
        }
        for (AlizaQuery query : queryArr) {
            list.add(query.getCondition());
        }
        return this;
    }

    public AlizaQuery<T> or(AlizaQuery... queryArr) {
        return operator(Operator.OR, queryArr);
    }

    public AlizaQuery<T> and(AlizaQuery... queryArr) {
        return operator(Operator.AND, queryArr);
    }

    public AlizaQuery<T> and(List<Map<String, Object>> maps) {
        maps.forEach(map -> operator(Operator.AND, new AlizaQuery(dao, map)));
        return this;
    }

    public AlizaQuery<T> greaterThan(String key, Object value) {
        appendThan(key, Operator.GT, value);
        return this;
    }

    public AlizaQuery<T> greaterThanEquals(String key, Object value) {
        appendThan(key, Operator.GTE, value);
        return this;
    }

    public AlizaQuery<T> lessThan(String key, Object value) {
        appendThan(key, Operator.LT, value);
        return this;
    }

    public AlizaQuery<T> lessThanEquals(String key, Object value) {
        appendThan(key, Operator.LTE, value);
        return this;
    }

    public AlizaQuery<T> notIn(String key, List list) {
        if (list == null) {
            list = Collections.emptyList();
        }
        return notIn(key, list.toArray());
    }

    public AlizaQuery<T> notIn(String key, Object... values) {
        appendIn(key, Operator.NIN, values);
        return this;
    }

    public AlizaQuery<T> all(String key, List list) {
        if (list == null) {
            list = Collections.emptyList();
        }
        return all(key, list.toArray());
    }

    public AlizaQuery<T> all(String key, Object... values) {
        append(key, Operator.ALL, values);
        return this;
    }

    public AlizaQuery<T> regex(String key, String regex) {
        append(key, null, Pattern.compile(regex));
        return this;
    }

    public AlizaQuery<T> size(String key, int value) {
        append(key, Operator.SIZE, value);
        return this;
    }

    public AlizaQuery<T> mod(String key, int divisor, int remainder) {
        append(key, Operator.MOD, Arrays.asList(divisor, remainder));
        return this;
    }

    public AlizaQuery<T> existsField(String key) {
        append(key, Operator.EXISTS, Boolean.TRUE);
        return this;
    }

    public AlizaQuery<T> notExistsField(String key) {
        append(key, Operator.EXISTS, Boolean.FALSE);
        return this;
    }

    public AlizaQuery<T> where(String whereStr) {
        append(Operator.WHERE, null, whereStr);
        return this;
    }

    public AlizaQuery<T> withinCenter(String key, double x, double y, double radius) {
        Document document = new Document(Operator.CENTER, new Object[]{new Double[]{x, y}, radius});
        append(key, Operator.WITHIN, document);
        return this;
    }

    public AlizaQuery<T> withinBox(String key, double x1, double y1, double x2, double y2) {
        Document document = new Document(Operator.BOX, new Object[]{new Double[]{x1, y1}, new Double[]{x2, y2}});
        append(key, Operator.WITHIN, document);
        return this;
    }

    public AlizaQuery<T> near(String key, double x, double y) {
        append(key, Operator.NEAR, new Double[]{x, y});
        return this;
    }

    public AlizaQuery<T> near(String key, double x, double y, double maxDistance) {
        append(key, Operator.NEAR, new Double[]{x, y, maxDistance});
        return this;
    }

    public AlizaQuery<T> slice(String key, long num) {
        Document document = new Document(Operator.SLICE, num);
        return addSlice(key, document);
    }

    public AlizaQuery<T> slice(String key, long begin, long length) {
        Document document = new Document(Operator.SLICE, new Long[]{begin, length});
        return addSlice(key, document);
    }

    private AlizaQuery<T> addSlice(String key, Document document) {
        if (slices == null) {
            slices = new Document();
        }
        slices.put(key, document);
        dao.getKeyFields().put(key, document);
        if (fields == null) {
            fields = new Document();
        }
        fields.put(key, document);
        return this;
    }

    public AlizaQuery<T> returnFields(String... fieldNames) {
        return specifyFields(1, fieldNames);
    }

    public AlizaQuery<T> notReturnFields(String... fieldNames) {
        return specifyFields(0, fieldNames);
    }

    private AlizaQuery<T> specifyFields(int value, String... fieldNames) {
        if (fields == null) {
            fields = new Document();
        }
        Arrays.stream(fieldNames).filter(o -> fields.get(o) != null).forEach(o -> fields.put(o, value));
        fieldsSpecified = true;
        return this;
    }

    public AlizaQuery<T> sort(String orderBy) {
        this.orderBy = orderBy;
        return this;
    }

    public AlizaQuery<T> pageNumber(int pageNumber) {
        this.pageNumber = pageNumber;
        return this;
    }

    public AlizaQuery<T> pageSize(int pageSize) {
        this.pageSize = pageSize;
        return this;
    }

    private void checkSingle() throws DBQueryException {
        if (orderBy != null || pageNumber != 0 || pageSize != 0) {
            throw new DBQueryException("You should use results() to get a list, when you use sorting or pagination");
        }
    }

    public T result() {
        try {
            checkSingle();
        } catch (DBQueryException ex) {
            logger.error(ex.getMessage(), ex);
        }
        Document document;
        MongoCollection<Document> coll = dao.getCollection();
        if (fieldsSpecified) {
            document = coll.find(condition).projection(fields).first();
        } else if (slices != null) {
            document = coll.find(condition).projection(slices).first();
        } else {
            document = coll.find(condition).first();
        }
        return MapperUtil.fromDocument(dao.getEntityClass(), document);
    }

    public List<T> results() {
        long begin = System.currentTimeMillis();
        FindIterable<Document> fluent;
        MongoCollection<Document> coll = dao.getCollection();
        if (fieldsSpecified) {
            fluent = coll.find(condition).projection(fields);
        } else {
            fluent = coll.find(condition).projection(dao.getKeyFields());
        }
        if (orderBy != null) {
            fluent.sort(MapperUtil.getSort(orderBy));
        }
        if (pageNumber > 0 && pageSize > 0) {
            fluent.skip((pageNumber - 1) * pageSize).limit(pageSize);
        }
        List<T> list = MapperUtil.toList(dao.getEntityClass(), fluent.iterator());
        long end = System.currentTimeMillis();
        if (end - begin > 1500) {
            System.out.println(String.format("[%s] query: %s, sort: %s (cost: %s ms)", MapperUtil.getDBName(dao.getEntityClass()), JSON.serialize(condition), JSON.serialize(orderBy), System.currentTimeMillis() - begin));
        }
        return list;
    }

    public long count() {
        return dao.getCollection().count(condition);
    }

    public boolean exists() {
        return dao.getCollection().count(condition) > 0;
    }

    public List distinct(String key) {
        return distinct(key, String.class);
    }

    public List distinct(String key, Class<?> clazz) {
        return dao.getCollection().distinct(key, clazz).filter(condition).into(new ArrayList<>());
    }

}
